refactor, feat: 네트워크 리팩토링, 점검 페이지 구현#362
Conversation
There was a problem hiding this comment.
Pull request overview
This PR refactors the iOS networking layer by consolidating per-domain request/refresh logic into a single NetworkService backed by an Alamofire RequestInterceptor (adapt + retry), and adds a global “maintenance/error” screen that is presented on server 5XX responses.
Changes:
- Centralize requests into
NetworkServiceand remove duplicated per-service token-refresh logic. - Add
Interceptorto inject access tokens and retry once after refreshing tokens on 401. - Introduce
ErrorViewControllerand present it viaSceneDelegatewhen 5XX errors occur.
Reviewed changes
Copilot reviewed 31 out of 33 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| koin.xcodeproj/project.pbxproj | Adds new networking/UI/CoreData files to the Xcode project and reorganizes groups. |
| Koin/Presentation/Home/Home/SubViews/ErrorViewController.swift | New error/maintenance UI screen with “go to main” action. |
| Koin/Data/Service/UserService.swift | Removes per-method refresh logic and routes calls through unified NetworkService. |
| Koin/Data/Service/TimetableService.swift | Replaces local Alamofire request helper with NetworkService. |
| Koin/Data/Service/ShopService.swift | Removes retry flag + refresh logic; routes via NetworkService. |
| Koin/Data/Service/NoticeListService.swift | Removes retry flag + refresh logic; routes via NetworkService + CoreData fallback paths. |
| Koin/Data/Service/NotiService.swift | Removes refresh logic and relies on interceptor-based auth/retry. |
| Koin/Data/Service/Network/NetworkService.swift | Adds interceptor-backed request overloads and posts “ServerError” notification on 5XX. |
| Koin/Data/Service/Network/MockNetworkService.swift | Adds a mock network service implementation (not yet integrated into services). |
| Koin/Data/Service/Network/Interceptor.swift | Adds Alamofire RequestInterceptor for auth header injection + refresh-on-401 retry. |
| Koin/Data/Service/Network/API/UserAPI.swift | Removes per-endpoint Authorization header injection (delegated to interceptor). |
| Koin/Data/Service/Network/API/TimetableAPI.swift | Removes per-endpoint Authorization header injection (delegated to interceptor). |
| Koin/Data/Service/Network/API/ShopAPI.swift | Removes per-endpoint Authorization header injection (delegated to interceptor). |
| Koin/Data/Service/Network/API/NoticeListAPI.swift | Removes per-endpoint Authorization header injection (delegated to interceptor). |
| Koin/Data/Service/Network/API/NotiAPI.swift | Removes Authorization header injection; keeps Content-Type only. |
| Koin/Data/Service/Network/API/LostItemAPI.swift | Removes per-endpoint Authorization header injection (delegated to interceptor). |
| Koin/Data/Service/Network/API/DiningAPI.swift | Removes per-endpoint Authorization header injection (delegated to interceptor). |
| Koin/Data/Service/Network/API/ChatAPI.swift | Removes per-endpoint Authorization header injection (delegated to interceptor). |
| Koin/Data/Service/Network/API/AbTestAPI.swift | Removes Authorization header injection (delegated to interceptor). |
| Koin/Data/Service/LostItemService.swift | Replaces local request helpers and removes refresh logic. |
| Koin/Data/Service/LogAnalyticsService.swift | Minor formatting-only change. |
| Koin/Data/Service/LandService.swift | Replaces local request helper with NetworkService. |
| Koin/Data/Service/DiningService.swift | Removes retry flag + refresh logic; routes via NetworkService. |
| Koin/Data/Service/CoreService.swift | Replaces local request helper with NetworkService. |
| Koin/Data/Service/CoreData/CoreDataService.swift | Adds a CoreData service wrapper (singleton) and moves it into a new group. |
| Koin/Data/Service/ChatService.swift | Removes refresh logic and routes via unified NetworkService. |
| Koin/Data/Service/BusService.swift | Switches from mock request helper to NetworkService. |
| Koin/Data/Service/AbTestService.swift | Removes retry flag/refresh logic and preserves accessHistoryId bootstrap flow. |
| Koin/Data/Repository/DefaultShopRepository.swift | Updates service call signature (removes retry parameter). |
| Koin/Data/Repository/DefaultNoticeListRepository.swift | Updates service call signature (removes retry parameter). |
| Koin/Data/Repository/DefaultDiningRepository.swift | Updates service call signature (removes retry parameter). |
| Koin/Data/Repository/DefaultAbTestRepository.swift | Updates service call signature (removes retry parameter). |
| Koin/Apps/SceneDelegate.swift | Observes “ServerError” notification and presents ErrorViewController. |
Comments suppressed due to low confidence (2)
Koin/Data/Service/Network/NetworkService.swift:121
uploadFilesusesapi.asMultipartRequest(...)which uploads without the NetworkService interceptor. Since auth headers were removed from ShopAPI, this upload will no longer receive the Authorization header or 401 refresh retry. Route uploads through an AlamofireSession/request that uses the same interceptor, or extendasMultipartRequest/NetworkService to accept and apply the interceptor.
Koin/Data/Service/Network/NetworkService.swift:170- Server-error detection relies on
ErrorResponse.codebeing a numeric string. When decoding fails (e.g. non-JSON 5XX body) you createErrorResponse(code: "", ...), so the maintenance screen won’t be triggered. Consider using the HTTP status code (available in the response) to trigger the ServerError notification, and/or populatingcodewith the status code when you can’t decode.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| func checkLogin() -> AnyPublisher<Bool, Never> { | ||
| networkService.request(api: UserAPI.checkLogin) | ||
| return (networkService.request(api: UserAPI.checkLogin) as AnyPublisher<Void, ErrorResponse>) | ||
| .map { _ in true } | ||
| .catch { [weak self] error -> AnyPublisher<Bool, Never> in | ||
| guard let self = self else { | ||
| return Just(false).eraseToAnyPublisher() | ||
| } | ||
| if error.code == "401" { | ||
| return self.networkService.refreshToken() | ||
| .flatMap { _ in self.networkService.request(api: UserAPI.checkLogin) } | ||
| .map { _ in true } | ||
| .replaceError(with: false) | ||
| .eraseToAnyPublisher() | ||
| } else { | ||
| return Just(false).eraseToAnyPublisher() | ||
| } | ||
| } | ||
| .replaceError(with: false) |
There was a problem hiding this comment.
These explicit casts are needed because NetworkService overloads differ only by failure type. This is brittle and can hide type mismatches at runtime. Consider renaming the overloads (e.g. requestErrorResponse vs request) or using a generic error wrapper so call sites don’t require forced casts.
| import UIKit | ||
|
|
||
| final class ErrorViewController: UIViewController { |
There was a problem hiding this comment.
then and SnapKit (.snp) are used in this file, but only UIKit is imported. This won’t compile unless you add the needed imports (e.g. Then/SnapKit) or remove the usages.
| private let titleLabel = UILabel().then { | ||
| $0.text = "개발 중인 페이지입니다" | ||
| $0.font = .appFont(.pretendardSemiBold, size: 20) | ||
| $0.textColor = .appColor(.primary500) | ||
| } | ||
|
|
||
| private let subTitleLabel = UILabel().then { | ||
| let paragraphStyle = NSMutableParagraphStyle() | ||
| paragraphStyle.lineHeightMultiple = 1.40 | ||
| paragraphStyle.alignment = .center | ||
|
|
||
| $0.attributedText = NSAttributedString( | ||
| string: "죄송합니다. 현재 개발 중인 페이지입니다.\n최대한 빠르게 오픈하도록 하겠습니다.", | ||
| attributes: [ | ||
| .font : UIFont.appFont(.pretendardMedium, size: 14), | ||
| .foregroundColor : UIColor.appColor(.neutral500), | ||
| .paragraphStyle : paragraphStyle | ||
| ]) | ||
| $0.numberOfLines = 2 |
There was a problem hiding this comment.
The copy says "개발 중인 페이지입니다" which reads like a “work in progress” page, but this view is presented on 5XX server errors per the PR description. Consider updating the title/subtitle to a maintenance/outage message so users aren’t misled.
| import UIKit | ||
|
|
||
| class SceneDelegate: UIResponder, UIWindowSceneDelegate { | ||
|
|
||
| var window: UIWindow? | ||
| var urlParameters: [String: String]? | ||
|
|
||
| override init() { | ||
| super.init() | ||
| NotificationCenter.default.addObserver(self, selector: #selector(presentErrorViewController), name: NSNotification.Name("ServerError"), object: nil) | ||
| } |
There was a problem hiding this comment.
then is used later when creating ErrorViewController, but this file doesn’t import Then. This will fail to compile unless Then is imported (or the .then usage is removed).
| @objc private func presentErrorViewController() { | ||
|
|
||
| if let navigationController = window?.rootViewController as? UINavigationController { | ||
|
|
||
| let homeViewController = makeHomeViewController() | ||
| let completion: ()->Void = { | ||
| navigationController.setViewControllers([homeViewController], animated: false) | ||
| } | ||
| let errorViewController = ErrorViewController(completion: completion).then { | ||
| $0.modalPresentationStyle = .fullScreen | ||
| } | ||
| navigationController.present(errorViewController, animated: true) | ||
| } |
There was a problem hiding this comment.
presentErrorViewController can be invoked on a background thread (the notification is posted from networking code). Presenting view controllers must happen on the main thread, and repeated 5XXs can trigger multiple presentations. Dispatch to the main queue and guard against presenting the error screen more than once.
| private let waringImageView = UIImageView().then { | ||
| let configuration = UIImage.SymbolConfiguration.init(pointSize: 60, weight: .light) | ||
| $0.image = UIImage(systemName: "exclamationmark.triangle", withConfiguration: configuration) | ||
| $0.tintColor = .appColor(.neutral500) | ||
| } |
There was a problem hiding this comment.
Property name waringImageView looks like a typo (should be warningImageView). This makes the code harder to read/search and propagates throughout constraints/usages.
| private func requestWithResponse<T: Decodable>(api: URLRequestConvertible) -> AnyPublisher<T, Error> { | ||
| return AF.request(api) | ||
| .publishDecodable(type: T.self) | ||
| .value() | ||
| .mapError { $0 as Error } | ||
| .eraseToAnyPublisher() |
There was a problem hiding this comment.
refreshToken() uses AF.request(api).publishDecodable(...).value() without .validate(), so non-2xx responses may be treated as successes if they decode. Add validation (or explicit status-code checks) so refresh only succeeds on 2xx.
|
|
||
| refreshToken().sink( receiveValue: { isRefreshed in | ||
| completion(isRefreshed ? .retry : .doNotRetryWithError(error)) | ||
| }).store(in: &subscriptions) |
There was a problem hiding this comment.
Each retry stores a new AnyCancellable into subscriptions and never removes it. Over time this can grow unbounded (even though publishers finish). Consider removing cancellables on completion, or using a different approach that doesn’t retain every finished subscription.
| refreshToken().sink( receiveValue: { isRefreshed in | |
| completion(isRefreshed ? .retry : .doNotRetryWithError(error)) | |
| }).store(in: &subscriptions) | |
| var cancellable: AnyCancellable? | |
| cancellable = refreshToken() | |
| .sink( | |
| receiveCompletion: { [weak self] _ in | |
| if let cancellable = cancellable { | |
| self?.subscriptions.remove(cancellable) | |
| } | |
| }, | |
| receiveValue: { isRefreshed in | |
| completion(isRefreshed ? .retry : .doNotRetryWithError(error)) | |
| } | |
| ) | |
| if let cancellable = cancellable { | |
| subscriptions.insert(cancellable) | |
| } |
| // | ||
| // Retrier.swift | ||
| // koin | ||
| // | ||
| // Created by 홍기정 on 2/12/26. | ||
| // |
There was a problem hiding this comment.
Header comment says Retrier.swift but the file/class is Interceptor. This mismatch makes file navigation/search confusing; please update the header to match the filename.
| func createNotificationKeyword(requestModel: NoticeKeywordDto) -> AnyPublisher<NoticeKeywordDto, ErrorResponse> { | ||
| return networkService.requestWithResponse(api: NoticeListAPI.createNotificationKeyword(requestModel)) | ||
| .catch { [weak self] error -> AnyPublisher<NoticeKeywordDto, ErrorResponse> in | ||
| guard let self = self else { return Fail(error: error).eraseToAnyPublisher() } | ||
| if error.code == "401" { | ||
| return self.networkService.refreshToken() | ||
| .flatMap { _ in self.networkService.requestWithResponse(api: NoticeListAPI.createNotificationKeyword(requestModel)) | ||
| } | ||
| .catch { [weak self] _ -> AnyPublisher<NoticeKeywordDto, ErrorResponse> in | ||
| guard let self = self else { return Fail(error: error).eraseToAnyPublisher() } | ||
| return self.createCoreDataKeyword(requestModel: requestModel) | ||
| } | ||
| .eraseToAnyPublisher() | ||
| } else { | ||
| return self.createCoreDataKeyword(requestModel: requestModel) | ||
| } | ||
| guard let self else { return Fail(error: error).eraseToAnyPublisher() } | ||
| return self.createCoreDataKeyword(requestModel: requestModel) | ||
| } | ||
| .eraseToAnyPublisher() |
There was a problem hiding this comment.
createNotificationKeyword now falls back to createCoreDataKeyword for any error, which also inserts into CoreData and returns an error message implying login failure. This changes behavior for non-auth errors (e.g. 400/500/network) and can create unintended local keywords. Consider only falling back for specific cases (e.g. refresh failure / not logged in), and ensure the returned error message matches the actual failure.
여러 API를 동시에 호출할 때 토큰 갱신 로직이 중복 호출될 수 있는 문제를 개선했습니다. 스레드 안정성을 개선했습니다.
API를 호출할 때마다 헤더에 access token을 추가하기 위해 키체인에 접근하고 있습니다. KeychainWorker에 캐싱기능을 추가했습니다.
이미 present 중인 viewcontroller가 있는 경우, dismiss 후 present 합니다.
7d6a48f to
735b367
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 32 out of 34 changed files in this pull request and generated 5 comments.
Comments suppressed due to low confidence (4)
Koin/Data/Service/Network/NetworkService.swift:171
handleError(_ error: Error) -> ErrorResponseonly handlesErrorResponseand otherwise returns a generic “알 수 없는 에러”. In theErrorResponserequest paths you sometimes throw anAFError(e.g., unacceptableStatusCode when the body is empty), so 5XX errors can fail to post theServerErrornotification and you lose the HTTP status code context. Consider handlingAFErrorhere (or posting based onhttpResponse.statusCodebefore throwing) so 5XX reliably triggers the maintenance flow and the returnedErrorResponsepreserves the status code.
Koin/Data/Service/Network/NetworkService.swift:101uploadFilesusesapi.asMultipartRequest(...)which internally callsAF.upload(...)without theRequestInterceptor. Since this PR removesAuthorizationinjection from API headers and relies onInterceptor.adapt, multipart uploads will no longer include the access token and may start failing with 401. Consider performing the upload through aSession/AF.uploadcall that includes the same interceptor (or manually add theAuthorizationheader here).
Koin/Data/Service/Network/NetworkService.swift:29request(api:)is declared twice with the same parameter list and only the return type differs (AnyPublisher<Void, Error>vsAnyPublisher<Void, ErrorResponse>). Swift doesn’t support overloading solely by return type, so this won’t compile. Rename one API (e.g.,requestVoid/requestVoidWithErrorResponse) or introduce a generic error wrapper so the signatures differ at the call site.
Koin/Data/Service/Network/NetworkService.swift:69requestWithResponse(api:)is also declared twice with identical parameter lists and only differing by return error type (ErrorvsErrorResponse). This is not a valid overload in Swift and will cause an “invalid redeclaration” compile error. Consider renaming or using a single method that maps to the desired error type via an explicit parameter or separate wrapper.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let errorViewController = ErrorViewController(completion: completion).then { | ||
| $0.modalPresentationStyle = .fullScreen | ||
| } |
There was a problem hiding this comment.
ErrorViewController(...).then { ... } requires the Then library, but this file only imports UIKit. Unless there’s a module-wide re-export (none found), this will not compile. Add import Then here or avoid .then and set modalPresentationStyle directly.
|
|
||
| guard isPresentingPresentErrorViewController == false else { | ||
| return | ||
| } | ||
| isPresentingPresentErrorViewController = true | ||
|
|
||
| if let navigationController = window?.rootViewController as? UINavigationController { | ||
|
|
||
| let homeViewController = makeHomeViewController() | ||
| let completion: ()->Void = { [weak self] in | ||
| self?.isPresentingPresentErrorViewController = false | ||
| navigationController.setViewControllers([homeViewController], animated: false) | ||
| } | ||
| let errorViewController = ErrorViewController(completion: completion).then { | ||
| $0.modalPresentationStyle = .fullScreen | ||
| } | ||
|
|
||
| if let _ = navigationController.presentedViewController { | ||
| navigationController.dismiss(animated: true) { | ||
| navigationController.present(errorViewController, animated: true) | ||
| } | ||
| } else { | ||
| navigationController.present(errorViewController, animated: true) |
There was a problem hiding this comment.
presentErrorViewController() can be invoked from a background thread because the ServerError notification is posted from networking code. This method performs UIKit operations (present, dismiss, setViewControllers), which must run on the main thread. Wrap the body in DispatchQueue.main.async { ... } (or ensure the notification is delivered on the main queue).
| guard isPresentingPresentErrorViewController == false else { | |
| return | |
| } | |
| isPresentingPresentErrorViewController = true | |
| if let navigationController = window?.rootViewController as? UINavigationController { | |
| let homeViewController = makeHomeViewController() | |
| let completion: ()->Void = { [weak self] in | |
| self?.isPresentingPresentErrorViewController = false | |
| navigationController.setViewControllers([homeViewController], animated: false) | |
| } | |
| let errorViewController = ErrorViewController(completion: completion).then { | |
| $0.modalPresentationStyle = .fullScreen | |
| } | |
| if let _ = navigationController.presentedViewController { | |
| navigationController.dismiss(animated: true) { | |
| navigationController.present(errorViewController, animated: true) | |
| } | |
| } else { | |
| navigationController.present(errorViewController, animated: true) | |
| DispatchQueue.main.async { [weak self] in | |
| guard let self = self else { return } | |
| guard self.isPresentingPresentErrorViewController == false else { | |
| return | |
| } | |
| self.isPresentingPresentErrorViewController = true | |
| if let navigationController = self.window?.rootViewController as? UINavigationController { | |
| let homeViewController = self.makeHomeViewController() | |
| let completion: ()->Void = { [weak self] in | |
| self?.isPresentingPresentErrorViewController = false | |
| navigationController.setViewControllers([homeViewController], animated: false) | |
| } | |
| let errorViewController = ErrorViewController(completion: completion).then { | |
| $0.modalPresentationStyle = .fullScreen | |
| } | |
| if let _ = navigationController.presentedViewController { | |
| navigationController.dismiss(animated: true) { | |
| navigationController.present(errorViewController, animated: true) | |
| } | |
| } else { | |
| navigationController.present(errorViewController, animated: true) | |
| } |
| @@ -0,0 +1,117 @@ | |||
| // | |||
| // Retrier.swift | |||
There was a problem hiding this comment.
File header comment says Retrier.swift, but the file/class is Interceptor. This can confuse future maintenance and file searches—please update the header to match the actual filename/type.
| // Retrier.swift | |
| // Interceptor.swift |
| wrapperView.snp.makeConstraints { | ||
| $0.center.equalTo(wrapperViewLayoutGuide) | ||
| } |
There was a problem hiding this comment.
wrapperView is only centered inside wrapperViewLayoutGuide and its subviews are only constrained via centerX/vertical constraints. There are no leading/trailing (or width) constraints tying the content to a deterministic width, which can lead to ambiguous layout warnings and unexpected wrapping on different devices. Consider constraining wrapperView (or at least subTitleLabel) with leading/trailing insets and/or setting a max width relative to the safe area.
| private let titleLabel = UILabel().then { | ||
| $0.text = "개발 중인 페이지입니다" | ||
| $0.font = .appFont(.pretendardSemiBold, size: 20) | ||
| $0.textColor = .appColor(.primary500) | ||
| } | ||
|
|
||
| private let subTitleLabel = UILabel().then { | ||
| let paragraphStyle = NSMutableParagraphStyle() | ||
| paragraphStyle.lineHeightMultiple = 1.40 | ||
| paragraphStyle.alignment = .center | ||
|
|
||
| $0.attributedText = NSAttributedString( | ||
| string: "죄송합니다. 현재 개발 중인 페이지입니다.\n최대한 빠르게 오픈하도록 하겠습니다.", | ||
| attributes: [ |
There was a problem hiding this comment.
PR/issue description says this screen is shown on 5XX server errors (점검/장애 상황), but the UI copy here says “개발 중인 페이지입니다”. This looks like a mismatch with the intended maintenance page behavior; consider updating the title/subtitle strings to reflect a server 점검/일시적 오류 message.
AF.upload 를 호출하는 Router의 asMultipartRequest 메서드를, asMultipartFormData 로 변경했습니다. NetworkService에서 직접 AF.upload 를 호출하며 Interceptor를 사용합니다.
tryMap에서 Error, URLError를 throw하면 handleError 에서 Error를 ErrorResponse로 캐스팅에 실패합니다. tryMap에서 ErrorResponse만 throw하도록 개선했습니다. handleError에서 statusCode를 확인할 수 있도록 하기 위해, ErrorRespose의 프로퍼티에 statusCode를 추가했습니다.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 35 out of 37 changed files in this pull request and generated 8 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| private let titleLabel = UILabel().then { | ||
| $0.text = "개발 중인 페이지입니다" | ||
| $0.font = .appFont(.pretendardSemiBold, size: 20) | ||
| $0.textColor = .appColor(.primary500) | ||
| } |
There was a problem hiding this comment.
The PR description/issue says this screen is shown for 5XX “점검 페이지”, but the UI copy here says “개발 중인 페이지입니다” and apologizes for a page under development. This is likely the wrong message for a server maintenance/outage state; please update the strings to match the intended 5XX maintenance context.
| func request(api: URLRequestConvertible) -> AnyPublisher<Void, ErrorResponse> { | ||
| return AF.request(api, interceptor: interceptor) | ||
| .publishData() |
There was a problem hiding this comment.
This second request(api:) has the same signature as the earlier one (only the Failure type differs). This is an invalid overload in Swift and will break compilation. Consider exposing a single public request API (e.g., always returning ErrorResponse) and providing a helper to erase/map to Error where needed.
| import Foundation | ||
|
|
||
| struct ErrorResponse: Decodable, Error { | ||
| var statusCode: Int? |
There was a problem hiding this comment.
statusCode was added to ErrorResponse without a default value / custom initializer. This changes the synthesized memberwise init and will break existing call sites like ErrorResponse(code:..., message:...) across the app. Consider giving statusCode a default (var statusCode: Int? = nil) or adding an explicit init with statusCode defaulting to nil to keep source compatibility.
| var statusCode: Int? | |
| var statusCode: Int? = nil |
Koin/Apps/SceneDelegate.swift
Outdated
| private var isPresentingPresentErrorViewController = false | ||
|
|
There was a problem hiding this comment.
The flag name isPresentingPresentErrorViewController looks like a typo (“PresentingPresent”). Renaming to something like isPresentingErrorViewController would improve readability and reduce confusion when maintaining this logic.
| private var isPresentingPresentErrorViewController = false | |
| private var isPresentingErrorViewController = false | |
| @available(*, deprecated, message: "Use isPresentingErrorViewController instead.") | |
| private var isPresentingPresentErrorViewController: Bool { | |
| get { return isPresentingErrorViewController } | |
| set { isPresentingErrorViewController = newValue } | |
| } | |
| func request(api: URLRequestConvertible) -> AnyPublisher<Void, Error> { | ||
| return AF.request(api, interceptor: interceptor) | ||
| .validate() |
There was a problem hiding this comment.
request(api:) is declared twice in this type with the same parameter list and only the return type differing (AnyPublisher<Void, Error> vs AnyPublisher<Void, ErrorResponse>). Swift can’t overload functions by return type alone, so this won’t compile. Please rename one of the methods (e.g., requestVoid / requestVoidWithErrorResponse) or collapse to a single error type and map at call sites.
| func requestWithResponse<T: Decodable>(api: URLRequestConvertible) -> AnyPublisher<T, Error> { | ||
| return AF.request(api, interceptor: interceptor) | ||
| .validate() |
There was a problem hiding this comment.
requestWithResponse(api:) is also declared twice with identical parameters and only differing return type (AnyPublisher<T, Error> vs AnyPublisher<T, ErrorResponse>). This kind of return-type-only overloading won’t compile in Swift. Rename the methods or consolidate to a single failure type.
| func requestWithResponse<T: Decodable>(api: URLRequestConvertible) -> AnyPublisher<T, ErrorResponse> { | ||
| return AF.request(api, interceptor: interceptor) | ||
| .publishData() |
There was a problem hiding this comment.
This second generic requestWithResponse(api:) duplicates the earlier definition with the same parameter list, differing only in the failure type. Swift cannot disambiguate by return type, so this must be refactored (e.g., different method names or a single failure type).
| .tryMap { response in | ||
| guard let httpResponse = response.response else { | ||
| throw ErrorResponse(code: "NETWORK_ERROR", message: "서버 응답 오류") | ||
| } | ||
| if 200..<300 ~= httpResponse.statusCode { |
There was a problem hiding this comment.
This file still constructs ErrorResponse(code:message:) in several places (e.g., network/multipart failures). With the newly added statusCode member (and no default/init), these initializers won’t compile. After stabilizing ErrorResponse’s initializer (e.g., default statusCode = nil), please re-check this file for remaining call sites.
기본값 nil을 명시했습니다.
각 sink 로직에 중복된 로깅을 NetworkService로 분리합니다.
tryMap에서 URLError, AFError 를 throw 하면 handleError에서 항상 캐스팅에 실패하므로, ErrorResponse만을 throw 하도록 변경했습니다. ErrorResponse와 ErrorResponseDto를 분리했습니다. ErrorResponse를 직접 생성해여 throw 하는 기존 로직을, 미리 정해진 error를 throw 하도록 리팩토링했습니다.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 127 out of 128 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| static let fileManagerFailedDirectory = ErrorResponse(statusCode: nil, code: "FILEMANAGER_FAILED_DIRECTORY", message: "파일 저장 위치 찾기 실패") | ||
| static let deleteKeywordError = ErrorResponse(statusCode: nil, code: "DELETE_KEYWORD_ERROR", message: "로그인에 실패하여 코어데이터에서 키워드 삭제") | ||
| static let createKeywordError = ErrorResponse(statusCode: nil, code: "CREATE_KEYWORD_ERROR", message: "로그인에 실패하여 코어데이터에서 키워드 삭제") | ||
| } |
There was a problem hiding this comment.
createKeywordError uses the same message as deleteKeywordError ('키워드 삭제'). This makes the error misleading when keyword creation fails; adjust the message to reflect saving/creating the keyword.
| } else if status == errSecItemNotFound { | ||
| keychains.updateValue(nil, forKey: key) | ||
| return nil |
There was a problem hiding this comment.
Dictionaries can’t store nil values; keychains.updateValue(nil, forKey:) removes the key. This means the errSecItemNotFound path isn’t actually cached and read will query the Keychain again next time. If you want to cache misses, use a non-optional value type (e.g. enum) or track not-found keys separately.
| receiveValue: { [weak self] _ in | ||
| self?.lectureData.removeAll { | ||
| $0.classTime == lecture.classTime && $0.name == lecture.name && $0.professor == $0.professor | ||
| } |
There was a problem hiding this comment.
removeAll predicate compares $0.professor == $0.professor, which is always true. This will delete every lecture matching classTime+name regardless of professor. Compare against lecture.professor instead (same as deleteLectureById).
| private let titleLabel = UILabel().then { | ||
| $0.text = "개발 중인 페이지입니다" | ||
| $0.font = .appFont(.pretendardSemiBold, size: 20) | ||
| $0.textColor = .appColor(.primary500) | ||
| } | ||
|
|
||
| private let subTitleLabel = UILabel().then { | ||
| let paragraphStyle = NSMutableParagraphStyle() | ||
| paragraphStyle.lineHeightMultiple = 1.40 | ||
| paragraphStyle.alignment = .center | ||
|
|
||
| $0.attributedText = NSAttributedString( | ||
| string: "죄송합니다. 현재 개발 중인 페이지입니다.\n최대한 빠르게 오픈하도록 하겠습니다.", | ||
| attributes: [ | ||
| .font : UIFont.appFont(.pretendardMedium, size: 14), | ||
| .foregroundColor : UIColor.appColor(.neutral500), | ||
| .paragraphStyle : paragraphStyle | ||
| ]) | ||
| $0.numberOfLines = 2 | ||
| } |
There was a problem hiding this comment.
PR description says this screen is a maintenance page shown on 5XX server errors, but the title/subtitle copy says '개발 중인 페이지입니다'. This looks like the wrong user-facing message for a 5XX maintenance scenario; update the copy to reflect server 점검/장애 안내.
| let completion: ()->Void = { [weak self] in | ||
| self?.isPresentingErrorViewController = false | ||
| navigationController.setViewControllers([homeViewController], animated: false) | ||
| } | ||
| let errorViewController = ErrorViewController(completion: completion).then { |
There was a problem hiding this comment.
isPresentingErrorViewController is set back to false inside the button completion before the error VC is dismissed. If another 5XX occurs during the dismissal animation, this can re-enter and attempt to present again. Consider resetting the flag in the dismissal completion (or after presentedViewController is actually dismissed) instead of immediately.
| let completion: ()->Void = { [weak self] in | |
| self?.isPresentingErrorViewController = false | |
| navigationController.setViewControllers([homeViewController], animated: false) | |
| } | |
| let errorViewController = ErrorViewController(completion: completion).then { | |
| var errorViewController: ErrorViewController! | |
| let completion: ()->Void = { [weak self, weak navigationController, weak errorViewController] in | |
| guard let self = self, | |
| let navigationController = navigationController, | |
| let errorViewController = errorViewController else { return } | |
| errorViewController.dismiss(animated: true) { | |
| self.isPresentingErrorViewController = false | |
| navigationController.setViewControllers([homeViewController], animated: false) | |
| } | |
| } | |
| errorViewController = ErrorViewController(completion: completion).then { |
#️⃣연관된 이슈
📝작업 내용
각 도메인 Service의 request 메서드를 삭제하고, NetworkService의 request 메서드를 사용하도록 변경
NetworkService에 반환타입이 request 메서드가 구현되어있지만, 일부 도메인 Service에서 request 메서드를 별도 구현하고 혼용해서 사용하고 있습니다.
각 도메인 Service에 별도 구현된 request 메서드를 제거하고, NetworkService로 통합합니다.
반환 타입이 <T, Error>, <Void, Error> 인 request 메서드를 삭제하고 <T, ErrorResponse>, <Void, ErrorResponse> 로 통합
ErrorResponse가 정의되어있음에도 Error를 혼용해서 사용하고있습니다.
ErrorResponse만을 사용하도록 변경합니다.
ErrorResponse 를 직접 생성하지 않고, 미리 정해진 static 값을 사용하도록 변경
에러 발생시 ErrorResponse(code:message:)를 직접 생성해서 사용하고 있습니다.
ErrorResponse에 static으로 선언되어있는 값을 사용하도록 변경했습니다.
Interceptor의 retry 메서드로 토큰 갱신 구현
각 도메인 Service의 메서드에서 토큰 갱신을 직접 구현하고 있으며, 토큰 갱신이 누락된 메서드도 다수 존재합니다.
NetworkService의 request 메서드에서 Interceptor를 이용하여 토큰 갱신하도록 변경했습니다.
Interceptor의 adapt 메서드로 access token을 헤더에 추가 구현
API에서 헤더에 access token을 직접 추가하지 않고,
NetworkService의 request 메서드에서 Interceptor를 이용하여 access token을 헤더에 추가하도록 변경했습니다.
점검 페이지 UI 구현, 5XX 오류시 점검 페이지로 이동
5XX 오류 점검 페이지를 present 합니다.
'메인 화면 바로가기' 버튼을 누르면 HomeViewController로 돌아갑니다.
KeychainWorker 성능 개선
모든 API 호출에서 access token을 헤더에 주입하기 위해 Keychain에 접근합니다.
성능 개선을 위해 KeychainWorker에 딕셔너리를 추가하고 값을 캐싱합니다.
스크린샷 (선택)
💬리뷰 요구사항(선택)